早期的 JavaScript 變數只能用 var 宣告,後來 ES6 新增 let 、 const 。
這篇不會細講三個宣告方式的差異,網路上很多大神已經解釋得很好。另外,其實我現在已經沒在用 var ,但用 var 才能解釋宣告提升這個行為。
那麼進入正題,宣告提升 ( Hoisting ) 到底是什麼樣的行為?他們怎麼在 JS 中被解析的?
我認為從 JS 的 執行環境 ( execution context ) 機制說起更好理解:
JS 的運作機制是一行一行往下執行的單執行緒,每當解析器碰到一個函式被呼叫, 就會為該函式開啟一個 execution context ,我們常稱中文為執行環境。
但要記得,第一個執行環境皆是全域執行環境 ( Global Execution Context )會被創建,且即便你有多個 JS 檔,他們共用同個全域執行環境。我個人是會把網站的所有 JS 檔案,想成被包進同個函式中,當你開啟網站頁面時就是呼叫這個函式。
就像不同的火鍋料丟進同個火鍋裡,但這些料都是同個湯底(?
這會影響到,你在不同檔案的最外層(也就是全域環境)取了同名稱變數,解析器就會報錯,因為他們是共用同個全域執行環境。
爾後,其他函式被呼叫時都是一個個堆疊於 Global Execution Context 之上。
好咧!接著要細講 Execution Context 的機制
Execution Context 整個過程分為兩個階段 - 創建階段、執行階段,且 Execution Context 還分成全域執行環境以及函式執行環境。
第一階段:創建階段 Creation Phase
若是全域執行環境,會建立 global object
皆會創建 this ,而 全域的 this 就是 window 物件,函式的 this 則會依據多種情況做綁定
每個執行環境,都會將宣告的變數名稱以及函式放入記憶體的一個專屬空間中
( ! ) 這邊有個大坑,以下面例子說明
console.log(idol);
sing('I');
var idol = 'Taeyeon';
function sing(song){ console.log(song)};
console.log(idol);
在創建階段,記憶體僅會放入的是變數 idol 這個名稱以及 sing 函式的全部程式碼,就是所謂的「宣告提升 Hoisting」。
但 console.log(idol) 為什麼是 undefined?因為 JS 第一階段就會把所有變數名稱先寫在記憶體中,此時它會給所有變數賦予 undefined,直到第二階段開始逐行執行時,有賦值才給值,這也是為什麼第五行 console.log(idol); 就能得到我們想要的值,因為我們在第三行賦值了。
but !! sing('I')為什麼可以成功執行?因為 JS 在創建階段對於函式的宣告,會把整個函式包含程式碼( Function Statement )都放進記憶體。
這是一般變數跟函式在宣告提升上的差別!變數的宣告提升是不包含賦值這件事。
再看一個例子:
```javascript
cheer(idol); // error
var idol = 'Taeyeon';
var cheer = function(idol){
console.log(`cheer for ${idol}`)
};
cheer(idol); // cheer for Taeyeon
```
跟之前不同的是,cheer 我們用運算子的方式去賦值一個匿名函式,而非直接宣告成函式。這會導致放進整個記憶體的依然僅有 cheer 變數名稱,不包含匿名函式的程式碼,因此第一行呼叫 cheer(idol) 會報錯,系統沒辦法知道 cheer 是一個 function。
第二階段:執行階段 Execution Phase
當創建階段完成後,就會進入到執行階段,也就是逐行 run 程式碼的時候
執行階段的核心觀念在執行堆疊 Execution Stack ( 也稱為 Call Stack )
Stack 是一種資料結構,特色為後進先出,也就是最後進來的會優先執行
例子講解
我們現在有一個 concert 檔,從右邊可以看到我們演唱會表演嘉賓的值以及他即將做哪些表演。當 concert 執行時,左邊最下層會先創建一個 global execution context,裡面儲存所有變數名稱跟宣告的函式,創建階段結束後開始逐行跑右邊程式碼。
執行階段:
以上統整幾個重點
全域執行環境會先被創建
宣告提升發生在執行環境的創建階段 → 這邊要注意全域跟函式的執行環境都有創建階段,如果你在函式內宣告變數,所有行為都跟文章第一部分講的一樣,會在該函式的作用域內進行 Hoisting喔!
var idol = 'Taeyeon';
function sing(singer){
console.log(idol) // undefined 因為下一行 var idol; 已經被放進記憶體內 但還沒執行到賦值的動作
// 即便外層全域也有 idol,但被函式 exection context 裡的同名稱 idol 變數給取代
var idol = 'Key'
console.log(idol) // "Fine" 上一行賦值後就能成功取得值
}
sing(idol);
JS 的單執行緒機制讓程式同個時間只能做一件事,做完才接下一個
JS 的執行 stack 採後進先出
每個執行環境 execution context 執行完後會跳離 stack,如下圖紅箭頭,直到回到最底部的全域執行環境
最後稍微提一下「 暫時性死區 ( temporal dead zone,簡稱TDZ ) 」
為什麼開頭會說必須用 var 才能說明 hoisting呢?
這是因為 let 跟 const 的宣告提升會被 TDZ 蓋過去,TDZ 是一個時間點的概念,並非空間。
就我的認知上,我不覺得 let、const 不具有 hoisting 的效果,而是他們宣告提升後被自身特性給限制住。倘若已經宣告但尚未賦值,JS 也不會主動把它設為 undefined (更不用說 const 規定宣告時一定要有值 ),所以想取值就會直接報錯,錯誤好像會因瀏覽器不同而定,我比較常見的是 "ReferenceError: Cannot access 'idol' before initialization
console.log(idol); // 受 TDZ 影響 會報錯
let idol = 'Taegu';
let idol2;
console.log(idol2) // undefined
不過這件事有多派理解,大家就記得 let 、 const 在賦值前取用的話會報錯,而不是 undefined。
一起養成先宣告好再取用變數的習慣~
參考資料
Huli 大神 https://blog.techbridge.cc/2018/11/10/javascript-hoisting/
https://medium.com/itsems-frontend/javascript-execution-context-and-call-stack-e36e7f77152e
Udemy 課程